Custom handles become free; record who invited a member - #6
Merged
Conversation
…email The 402 tier gate on PUT /v1/users/me/handle is gone. Every hosted registration already gets a 14-day Pro trial (auth.rs:176), so the gate made anyone who claimed in their first fortnight permanently custom- handled anyway — it was a no-op carrying real machinery, and it landed on identity rather than on discovery. A member of a paying Business team was told "Custom handles are a Pro feature" because effective_tier_for_user reads only the user's own row. Claiming now requires email_verified and returns 403 EMAIL_NOT_VERIFIED otherwise, so a client can say "verify your email first" instead of "invalid handle". That response body already existed for the checkout gate; it moves to routes::mod so the two cannot drift. Search is untouched: (handle_is_custom AND handle LIKE ...) already says "custom handles are fuzzy-searchable, generated ones are exact-match only", and that property is about generated vs custom, never paid vs free. No new column, no migration, no backfill. The reserved-name lists are widened, since every account can now attempt a claim rather than only a paying one: mail-system roles, trust words and the terms a billing or verification prompt would use.
…egistration TeamMember.invited_by_display_name was null for every accepted invite: the two accept paths inserted (team_id, user_id) and dropped the invitation's invited_by, so the roster's LEFT JOIN on users had nothing to resolve. The registration auto-accept was worse. Its INSERT named a `role` column that team_members has not had since the roles migration, the error was discarded by `let _ =`, and the invitations were marked accepted anyway — so a user who registered with a pending invite consumed the invite and never joined the team, with no role row either. All three paths now share admit_member(), which writes the membership row with invited_by and assigns the builtin role. The upsert only fills a NULL inviter, so re-accepting cannot rewrite who brought a member in, and a link-only invite that arrives first is backfilled by a later accept.
This was referenced Aug 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two commits, both removing more than they add.
1. Custom handles become free
The 402 tier gate on
PUT /v1/users/me/handleis deleted. Every hosted registration already gets a 14-day Pro trial (auth.rs:176), so the gate made anyone who claimed in their first fortnight permanently custom-handled anyway — it was a no-op carrying real machinery, and it landed on identity rather than on discovery. A member of a paying Business team was told "Custom handles are a Pro feature" becauseeffective_tier_for_userreads only the user's own row.Claiming now requires
email_verifiedand returns 403{"error":"EMAIL_NOT_VERIFIED"}otherwise, so a client can say "verify your email first" instead of "invalid handle". That response body already existed for the checkout gate; it moves toroutes::modso the two cannot drift.Search is untouched.
(handle_is_custom AND LOWER(handle) LIKE $1)already says "custom handles are fuzzy-searchable, generated ones are exact-match only", and that property is about generated vs custom, never paid vs free. No new column, no migration, no backfill.The reserved-name lists are widened, since every account can now attempt a claim rather than only a paying one: mail-system roles, trust words, and the terms a billing or verification prompt would legitimately use.
impersonation_keystrips separators, sonoreplycoversno-replyandv3rifiedis caught.USER_SEARCH_RATE_LIMITwas reviewed and deliberately left at 60/min per user_id. The enumeration ceiling is set byLIMIT 8plus a stableORDER BY u.handlewith no offset — a larger searchable population makes each query less revealing, not more. Lowering it would only hurt type-ahead; the abuse brake that matters isSTRANGER_KNOCK_RATE_LIMIT.2. Record who invited a member — and actually admit them on registration
TeamMember.invited_by_display_namewas null for every accepted invite: both accept paths inserted(team_id, user_id)and dropped the invitation'sinvited_by, so the roster'sLEFT JOIN users invhad nothing to resolve.The registration auto-accept was worse. Its INSERT named a
rolecolumn thatteam_membershas not had since the roles migration, the error was discarded bylet _ =, and the invitations were marked accepted anyway — a user who registered with a pending invite consumed the invite and never joined the team, with no role row either.All three paths now share
admit_member(). The upsert only fills a NULL inviter, so re-accepting cannot rewrite who brought a member in, and a link-only invite that arrives first is backfilled by a later accept.Verification
cargo test— 243 passed, 0 failed (against a real Postgres, so the DB-backed tests actually ran rather than skipping).cargo clippy --all-targets -- -D warningsclean.New coverage: a free verified user can claim and is fuzzy-searchable afterwards; an unverified one is refused with 403; a generated handle is still not matched by a substring but does resolve exactly (the security property asserted directly); an expired trial can still claim; and
invited_byis recorded, backfilled from NULL, and never rewritten.Deploy order
The web portal ships first (VoltiusApp/web#7), then this, then the client. Migrations run in-process at startup — stop the old server, start the new one; no rolling restart.